Skip to content

Add per-device KeeShare sync mode#13089

Open
Lcstyle wants to merge 5 commits intokeepassxreboot:developfrom
Lcstyle:feature/keeshare-pr
Open

Add per-device KeeShare sync mode#13089
Lcstyle wants to merge 5 commits intokeepassxreboot:developfrom
Lcstyle:feature/keeshare-pr

Conversation

@Lcstyle
Copy link
Copy Markdown

@Lcstyle Lcstyle commented Feb 25, 2026

Summary

  • Adds a per-device sync mode for KeeShare where each device writes its own container file ({DEVICE_ID}.kdbx) into a shared directory, eliminating file conflicts when syncing via Syncthing/Nextcloud/etc.
  • Classic single-file mode is completely unchanged — if the path ends in .kdbx or .kdbx.share, it works exactly as before
  • Also fixes a Quick Unlock Polkit D-Bus marshalling bug (separate cherry-pickable commit)

How per-device mode works

When a KeeShare reference path points to a directory (instead of a .kdbx file), KeePassXC treats it as per-device mode:

  • Export: writes to {syncDir}/{DEVICE_ID}.kdbx
  • Import: reads all *.kdbx files in the directory except the device's own file
  • Device ID is auto-detected from the system's machine ID, or can be set manually in KeeShare settings

The existing ShareObserver separation (import on directory change, export on database save) naturally prevents feedback loops between devices.

Commits

Commit Description
Add per-device KeeShare sync mode Config, KeeShare, KeeShareSettings, ShareObserver, group sharing UI, 9 new unit tests
fix Quick Unlock QMap D-Bus marshalling Register QMap<QString, QString> as D-Bus metatype — fixes "Unregistered type" error on Polkit CheckAuthorization. Independent fix, cherry-pickable separately

Interop

Matching KeePassDX (Android) implementation: Kunzisoft/KeePassDX#2438

Discussion: #13080

Tested with real Syncthing sync between Fedora desktop and Pixel 7 running KeePassDX.

Test plan

  • All existing tests pass
  • 9 new unit tests for per-device mode detection, path generation, device ID
  • Classic single-file mode unchanged (path ending in .kdbx)
  • Directory path triggers per-device mode
  • Each device exports only its own container file
  • Import reads all other devices' containers
  • No sync storm between devices over Syncthing
  • Quick Unlock via Polkit works after D-Bus fix

Lcstyle and others added 2 commits February 25, 2026 11:35
Enable each KeePassXC instance to write its own container file
({DEVICE_ID}.kdbx) to a shared sync directory while importing from
all other devices' files. This eliminates Syncthing file conflicts
when multiple devices share a group.

When a KeeShare reference path points to a directory instead of a
.kdbx file, per-device mode activates automatically:
- Export writes to {syncDir}/{DEVICE_ID}.kdbx
- Import reads all .kdbx files in the directory except own device
- Classic single-file mode is fully preserved

Changes:
- Add KeeShare_DeviceId config key with auto-detection fallback
- Add Reference::isPerDeviceMode() detection based on path extension
- Extend ShareObserver with QFileSystemWatcher for directory watching
- Add importPerDeviceShares() for multi-file import from sync dirs
- Update EditGroupWidgetKeeShare with directory selection dialog
- Add Device Identity settings field to SettingsWidgetKeeShare
- Add KeeShare/PerDeviceSync custom data support for KeePassDX interop
- Add unit tests for per-device mode path detection

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Register QMap<QString,QString> as a D-Bus metatype so Polkit
CheckAuthorization can marshal the details argument. Without this,
Quick Unlock fails with "Unregistered type QMap<QString,QString>
passed in arguments".
Copilot AI review requested due to automatic review settings February 25, 2026 16:36
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request adds a per-device sync mode for KeeShare that eliminates file conflicts when syncing via tools like Syncthing or Nextcloud. The key innovation is allowing each device to write to its own container file ({DEVICE_ID}.kdbx) within a shared directory, instead of all devices writing to the same file. The PR also includes an independent fix for a Quick Unlock Polkit D-Bus marshalling bug.

Changes:

  • Per-device sync mode: When a KeeShare path points to a directory (vs. a .kdbx file), each device exports to {DEVICE_ID}.kdbx in that directory and imports from all other *.kdbx files found there
  • Device ID management: Auto-generated from system machine ID or manually configurable via settings UI
  • Quick Unlock D-Bus fix: Registers QMap<QString, QString> as D-Bus metatype to fix Polkit authorization marshalling errors

Reviewed changes

Copilot reviewed 15 out of 15 changed files in this pull request and generated 19 comments.

Show a summary per file
File Description
src/quickunlock/Polkit.cpp Adds D-Bus metatype registration for QMap<QString, QString> to fix Polkit marshalling
src/core/Config.h/cpp Adds KeeShare_DeviceId config key for storing device identifier
src/keeshare/KeeShare.h/cpp Implements deviceId() getter/setter with auto-generation and sanitization logic
src/keeshare/KeeShareSettings.h/cpp Adds isPerDeviceMode() method to detect directory-based sync paths
src/keeshare/ShareObserver.h/cpp Implements per-device import/export logic with QFileSystemWatcher for directories
src/keeshare/SettingsWidgetKeeShare.ui/cpp Adds device ID configuration UI field
src/keeshare/group/EditGroupWidgetKeeShare.ui/cpp Updates path selection dialog and validation for directory mode
tests/TestSharing.h/cpp Adds unit tests for per-device mode path detection
Comments suppressed due to low confidence (1)

src/keeshare/group/EditGroupWidgetKeeShare.cpp:158

  • The conflict detection logic doesn't account for per-device mode. When multiple groups export to the same directory in per-device mode, they each create separate device files ({DEVICE_ID}.kdbx), so there's no actual conflict. However, the current code would flag this as a "conflictExport" error (line 151). The conflict detection should either skip per-device mode paths or compare the full resolved export path (directory + device file) instead of just the directory path.
        const auto groups = m_database->rootGroup()->groupsRecursive(true);
        bool conflictExport = false;
        bool multipleImport = false;
        bool cycleImportExport = false;
        for (const auto* group : groups) {
            if (group->uuid() == m_temporaryGroup->uuid()) {
                continue;
            }
            const auto other = KeeShare::referenceOf(group);
            if (other.path != reference.path) {
                continue;
            }
            multipleImport |= other.isImporting() && reference.isImporting();
            conflictExport |= other.isExporting() && reference.isExporting();
            cycleImportExport |=
                (other.isImporting() && reference.isExporting()) || (other.isExporting() && reference.isImporting());
        }
        if (conflictExport) {
            m_ui->messageWidget->showMessage(tr("%1 is already being exported by this database.").arg(reference.path),
                                             MessageWidget::Error);
            return;

Comment on lines +252 to +266
// For SynchronizeWith, offer both file and directory selection
if (reference.type == KeeShareSettings::SynchronizeWith) {
// Try directory selection first for per-device sync
auto dirPath = fileDialog()->getExistingDirectory(
this, tr("Select per-device sync directory"), defaultDirPath);
if (!dirPath.isEmpty()) {
// Directory selected: per-device mode
m_ui->pathEdit->setText(dirPath);
selectPath();
FileDialog::saveLastDir("keeshare", dirPath);
updateSharingState();
return;
}
// User cancelled directory dialog; fall through to file dialog
}
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dialog flow is confusing: when SynchronizeWith is selected, the code shows a directory picker first, and if the user cancels, it falls through to show a file picker. This means canceling the directory picker doesn't actually cancel the operation - it forces the user into file selection mode. This is unintuitive UX. Consider either offering a choice upfront, or respecting the cancellation.

Copilot uses AI. Check for mistakes.
Comment thread src/keeshare/ShareObserver.cpp Outdated
Comment thread src/keeshare/ShareObserver.cpp
Comment thread src/keeshare/ShareObserver.cpp
Comment thread src/keeshare/KeeShare.cpp
Comment thread src/keeshare/ShareObserver.cpp
Comment thread src/keeshare/ShareObserver.cpp Outdated
Comment thread src/keeshare/KeeShare.h Outdated
Comment thread src/keeshare/group/EditGroupWidgetKeeShare.ui Outdated
Comment thread src/keeshare/ShareObserver.cpp Outdated
Comment on lines +405 to +419
if (reference.config.isPerDeviceMode()) {
// Per-device mode: export to {directory}/{DEVICE_ID}.kdbx
QDir dir(resolvedPath);
if (!dir.exists()) {
dir.mkpath(".");
}
const auto deviceFile = dir.absoluteFilePath(KeeShare::deviceId() + ".kdbx");

// Pause directory watcher during export
auto dirWatcher = m_dirWatchers.value(resolvedPath);
if (dirWatcher) {
dirWatcher->removePath(resolvedPath);
}

results << ShareExport::intoContainer(deviceFile, reference.config, reference.group);
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The conflict detection for exports doesn't account for per-device mode. When multiple groups export to the same directory in per-device mode, they each create separate device files ({DEVICE_ID}.kdbx) within that directory, so there's no actual conflict. However, the validation code before this region (lines 385-398, not shown in diff) treats this as a "Conflicting export target path" error. The per-device mode export logic should work with the conflict detection to allow multiple groups to export to the same directory when using per-device mode.

Copilot uses AI. Check for mistakes.
@Lcstyle
Copy link
Copy Markdown
Author

Lcstyle commented Feb 25, 2026

Damn! your copilot review is awesome! Caught a bunch of stuff! Fantastic man I love that, all automated too! @droidmonkey

…emove dead code

- split m_inFileUpdate into separate file/dir flags to prevent dropped updates
- check mkpath return value and report error on failure
- add QRegularExpressionValidator and length cap (32) to device ID
- clear device ID config when field emptied (allows auto-detect reset)
- remove unused hasPerDeviceConfig/perDeviceSyncPath and KeeShare_PerDeviceSync
- skip cycleImportExport warning in per-device mode (not a real conflict)
- move tooltip from pathLabel to pathEdit input field
- add testPerDeviceModeImportExport test coverage
@Lcstyle
Copy link
Copy Markdown
Author

Lcstyle commented Feb 25, 2026

Copilot Review: All 19 Comments Verified

Fixed (8 items):

# Comment Status
3 m_inFileUpdate shared between file/directory updates Fixed. Split into m_inFileUpdate + m_inDirUpdate (ShareObserver.h:91-92, .cpp:273,282,303)
4 mkpath(".") return value unchecked Fixed. Now checks return, emits error Result on failure (ShareObserver.cpp:409-414)
12 Device ID field missing input validator Fixed. QRegularExpressionValidator("[A-Za-z0-9]{0,32}") added (SettingsWidgetKeeShare.cpp:37-38)
7 Device ID sanitization lacks length validation Fixed. setDeviceId() now calls sanitized.truncate(32) (KeeShare.cpp:85)
15 Clearing device ID field doesn't clear config Fixed. Empty field now clears config via config()->set(Config::KeeShare_DeviceId, QString()) (SettingsWidgetKeeShare.cpp:80-83)
18 Dead code hasPerDeviceConfig()/perDeviceSyncPath() Fixed. Removed from KeeShare.h and KeeShare.cpp, along with unused KeeShare_PerDeviceSync constant
19 Tooltip on label instead of input field Fixed. Moved from pathLabel to pathEdit in EditGroupWidgetKeeShare.ui
13 Tests don't cover import/export flag behavior Fixed. Added testPerDeviceModeImportExport with 5 test rows (TestSharing.cpp:209-243)

Pushback — Copilot is wrong or overstating (6 items):

# Comment Reasoning
10 QTimer::singleShot missing this context Wrong. Line 274: QTimer::singleShot(100, this, [this, dirPath] {this IS the second parameter. Copilot misread the code.
6 deviceId() lazy generation is non-deterministic Disagree. Lazy init is standard Qt pattern (see own() at line 89 doing the same thing). The ID is generated once and persisted — subsequent calls return the same value. No inconsistency possible.
8 Race condition in directories().isEmpty() + addPath() Non-issue. Qt signals are queued on the same thread. handleDirectoryUpdated runs on the main thread, so no concurrent access. addPath on an already-watched path is a no-op in Qt.
14 isPerDeviceMode() relies on negative logic, fragile Disagree. This matches the existing codebase pattern — isContainerType() at KeeShare.cpp:280 does the same extension check. The two known extensions (.kdbx, .kdbx.share) haven't changed in years. Adding a new extension would require updating multiple places regardless.
11 Case-insensitive skip could skip two files on case-sensitive FS Theoretical only. Device IDs are alphanumeric-only and auto-generated from machineUniqueId(). No user will manually create both DEVICE1.kdbx and device1.kdbx. Case-insensitive comparison is the safer default for cross-platform sync (FAT32, exFAT, NTFS synced dirs).
1 Conflict detection doesn't account for per-device mode (suppressed comment) Partially addressed. cycleImportExport is now skipped for per-device mode. But conflictExport IS still a real conflict in per-device mode — two groups exporting to the same dir both write the same {DEVICE_ID}.kdbx file. Copilot's suggestion to skip per-device export conflict detection was wrong.

Acknowledged but not changing (5 items):

# Comment Reasoning
2 SynchronizeWith dialog shows dir picker then file picker on cancel Intentional design. This is a progressive disclosure pattern — most users picking "Synchronize" want per-device (dir). Those who want classic file mode can cancel and get the file dialog. Adding a choice dialog before the actual picker would be 3 clicks instead of 1-2.
5 Dir watcher not added if directory doesn't exist at init Already handled. exportShares() calls dirWatcher->addPath(resolvedPath) after mkpath (line 428). First export creates the dir and starts watching it. Before that, there's nothing to watch.
9 Brief window during export where dir changes could be missed Acceptable. The 100ms debounce timer in handleDirectoryUpdated already coalesces rapid changes. Any change during the brief export pause will trigger a new notification when the watcher resumes. Documenting this as a limitation is unnecessary complexity.
16 importPerDeviceShares lacks integration test coverage Can't unit test this. It depends on ShareImport::containerInto() which needs a full Database, Crypto, and file I/O stack. Integration tests belong in a separate test suite that the existing TestSharing binary doesn't link against. The function is straightforward — filter .kdbx files, skip own, import each.
17 Export path construction and watcher pause/resume lack test coverage Same reason as #16. Requires full ShareObserver + Database + filesystem integration test harness.

@Lcstyle
Copy link
Copy Markdown
Author

Lcstyle commented Mar 1, 2026

@droidmonkey hi there!

Comment thread src/keeshare/KeeShare.cpp Outdated
Comment thread src/keeshare/KeeShareSettings.cpp Outdated
Comment on lines +289 to +294
bool Reference::isPerDeviceMode() const
{
return !path.isEmpty()
&& !path.endsWith(".kdbx", Qt::CaseInsensitive)
&& !path.endsWith(".kdbx.share", Qt::CaseInsensitive);
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, this check is way too loose. We know what directories are per-device mode enabled already, test against what we know.

@droidmonkey
Copy link
Copy Markdown
Member

I did a quick run through, copilot made some great recommendations so read through the remaining comments and make adjustments.

Move all sanitization (strip non-alphanumeric, truncate to 32, fallback
to DEFAULT) into setDeviceId() so it's consistent regardless of call
site. deviceId() now delegates to setDeviceId() and re-reads the
sanitized value.

isPerDeviceMode() now uses QFileInfo::isDir() as a positive filesystem
check — no extension-based heuristics. Tests updated to create real
temp directories instead of using synthetic paths.
Copilot AI review requested due to automatic review settings March 2, 2026 17:26
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 15 out of 15 changed files in this pull request and generated 2 comments.

Comment thread src/keeshare/ShareObserver.cpp
Comment thread src/keeshare/ShareObserver.cpp Outdated
Change isPerDeviceMode(const QString&) to isPerDeviceMode(const QDir& baseDir)
so the method resolves this->path against the caller-provided base directory.
This fixes silent misclassification when reference paths are relative, since
QFileInfo previously resolved against process CWD instead of the database
directory.

Override Result.path with the actual device file path in importPerDeviceShares
so status messages show which specific container failed, not just the sync
directory.

Add regression tests for relative directory and file paths.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants